大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 22 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容
在上個章節的最後我們不僅有散射光、反射光,還有使得物體表面有更多凹凸細節的 normal map,筆者從這個實做成果再進行修改,材質改用 Commission - Medieval 的 scale
,並且加上環境光 u_ambient
使物體有一個最低亮度,最後讓相機操作更加完整:透過拖曳平移視角,使用滑鼠右鍵、滾輪或是多指手勢可對視角進行縮放、轉動。這個章節便從這個進度作為起始點
05-framebuffer-shadow.html
/ 05-framebuffer-shadow.js
完整程式碼可以在這邊找到:
可以發現點光源改回平行光,而且會隨著時間改變方向,然後地板變成全黑的了,因為這是本章接下來要實做的
簡單來說,這是一個 WebGL 渲染的目標,本系列文到這邊渲染的目標皆為 <canvas />
元件,畫給使用者看的,而 framebuffer 可以改變這件事,其中一個選項是使之渲染到 texture 上
為什麼要渲染到 texture 上呢?假設今天有一面鏡子,鏡子上所看到的圖像,等同於從鏡子中的相機看回原本世界,因此可以先從鏡子內繪製一次場景到一個 texture 上,接著繪製鏡子時就可以拿此 texture 來繪製;甚至感覺比較沒有關聯的陰影效果也需要透過 framebuffer 的功能,事先請 GPU 做一些運算,在正式『畫』的時候使用
在實做鏡面或是陰影之前,先來專注在 framebuffer 這個功能上,畢竟想想也知道鏡子、陰影需要的不會只是 framebuffer,還需要一些能夠讓物件位置成像能對得起來的方法,因此本篇的目標是:渲染到 texture 上,接著渲染地板時使用該 texture,效果上來說像是把畫面上的球體變到黑色地板中
首先在 setup()
中建立 framebuffer,並且把目標對準(bind)新建立的 framebuffer:
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
同時也建立好 texture 作為 framebuffer 渲染的目標,筆者先命名為 fb
,framebuffer 的縮寫:
textures.fb = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, textures.fb);
const width = 2048;
const height = 2048;
gl.texImage2D(
gl.TEXTURE_2D,
0, // level
gl.RGBA, // internalFormat
width,
height,
0, // border
gl.RGBA, // format
gl.UNSIGNED_BYTE, // type
null, // data
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
可以看到建立了一個 2048x2048 大小的 texture,並且傳 null
讓資料留白,同時也得關閉 mipmap 功能,畢竟渲染到 texture 上之後,如果還要呼叫 gl.generateMipmap()
計算縮圖就太浪費資源了,有需要的話可以回去參考 Day 7 的講解
然後是建立『framebuffer 與 texture 的關聯』:
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0, // attachment
gl.TEXTURE_2D,
textures.fb,
0, // level
);
呼叫 gl.framebufferTexture2D()
使得當下對準的 framebuffer 的一個 attachment 對準指定的 texture,因為我們現在關心的是顏色,gl.COLOR_ATTACHMENT0
使得渲染到 framebuffer 時,『顏色(gl_FragColor
)』部份會寫入,最後 level
表示要寫入 mipmap 的哪一層
建立完成後,在 app
下加入 framebuffers
物件來存放建立好的 framebuffer:
async function setup() {
// ...
+ const framebuffers = {}
{
const framebuffer = gl.createFramebuffer();
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
// ...
+ framebuffers.fb = {
+ framebuffer, width, height,
+ };
}
return {
gl,
programInfo,
- textures, objects,
+ textures, framebuffers, objects,
state: {
fieldOfView: degToRad(45),
cameraRotationXY: [degToRad(-45), 0],
cameraDistance: 15,
cameraViewing: [0, 0, 0],
cameraViewingVelocity: [0, 0, 0],
lightRotationXY: [0, 0],
},
time: 0,
};
}
如果接下來會需要先渲染到 framebuffer,再渲染到畫面,那麼可以想見某些物體會需要繪製兩次,為了避免重複程式碼,筆者把標注 ball
, ground
的花括弧 {}
區域獨立成兩個 function:
function renderBall(app, viewMatrix)
function renderGround(app, viewMatrix)
準備完成後,在 render()
設定好全域 uniform 之後,呼叫 gl.bindFramebuffer()
切換到 framebuffer, 像這樣渲染到 framebuffer 並寫入 textures.fb
:
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffers.fb.framebuffer);
gl.viewport(0, 0, framebuffers.fb.width, framebuffers.fb.height);
renderBall(app, viewMatrix);
要記得使用 gl.viewport()
設定渲染長寬跟 texture 一樣大,接著就跟原本渲染到畫面上一樣,因此直接呼叫 renderBall()
渲染球體
那麼要怎麼讓渲染目標切換回 <canvas />
呢?呼叫 gl.bindFramebuffer()
並傳入 null
即可,不過一樣要記得把渲染長寬設定好:
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.canvas.width = gl.canvas.clientWidth;
gl.canvas.height = gl.canvas.clientHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
renderGround(app, viewMatrix);
呼叫 renderGround()
渲染地板的同時,設定其 uniform 時在 u_diffuseMap
填上 textures.fb
使地板顯示在 framebuffer 時渲染的樣子:
function renderGround(app, viewMatrix) {
// ...
twgl.setUniforms(programInfo, {
u_matrix: matrix4.multiply(viewMatrix, worldMatrix),
u_worldMatrix: worldMatrix,
u_normalMatrix: matrix4.transpose(matrix4.inverse(worldMatrix)),
u_diffuse: [0, 0, 0],
- u_diffuseMap: textures.nil,
+ u_diffuseMap: textures.fb,
u_normalMap: textures.nilNormal,
u_specular: [1, 1, 1],
u_specularExponent: 200,
u_emissive: [0, 0, 0],
});
twgl.drawBufferInfo(gl, objects.ground.bufferInfo);
}
存檔重整,轉一下的確看得出來球體渲染在一幅畫上的感覺,但是平移會看到殘影:
有殘影是因為上一次渲染到 texture 的東西不會被自動清除,因此透過 Day 1 的油漆工具清除 framebuffer-texture:
async function setup() {
// ...
gl.enable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
+ gl.clearColor(1, 1, 1, 1);
return {
// ...
};
}
function render(app) {
// ...
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffers.fb.framebuffer);
gl.viewport(0, 0, framebuffers.fb.width, framebuffers.fb.height);
+ gl.clear(gl.COLOR_BUFFER_BIT);
renderBall(app, viewMatrix);
}
雖然清除的顏色是白色,但是因為光線方向會移動導致散射(textures.fb
設定在 u_diffuseMap
上)亮度下降,除此之外球體成功透過 framebuffer 渲染到 texture,並繪製在平面上了,完整程式碼可以在這邊找到: